STV2022 – Store tekstdata

Solveig Bjørkholt og Martin Søyland

2022-09-06

1 Introduksjon

Velkommen til STV2022 – Store teksdata!

Dette er en arbeidsbok som går gjennom de forskjellige delene i kurset STV2022 – Store teksdata, med tilhørende R-kode. Meningen med arbeidsboken, er at den kan brukes som forslag til implementering av metoder i semesteroppgaven. Merk likevel at dette ikke er en fasit!

Om du skulle finne feil i dokumentet, legg gjerne inn en issue på github så får vi fikset det i en fei.

Siste endring:

## siste puss på forelesning 03 (2022-09-05)

1.1 Kort om kurset

I kurset skal vi bli kjent med analyseprosessen av store tekstdata: Hvordan samler man effektivt og redelig store mengder politiske tekster? Hva må til for å gjøre slike tekster klare for analyse? Og hvordan kan vi analysere tekstene?

Politikere og politiske partier produserer store mengder tekst hver dag. Om det er gjennom debatter, taler på Stortinget, lovforslag fra regjeringen, høringer, offentlige utredninger med mer, er digitaliserte politiske tekster i det offentlige blitt mer tilgjengelig de siste tiårene. Dette har åpnet et mulighetsrom for tekstanalyse som ikke var mulig/veldig vanskelig og tidkrevende før.

Det kan ofte være vanskelig å finne mønster som kan svare på spørsmål og teorier vi har i statsvitenskap i disse store tekstsamlingene. Derfor kan vi se til metoder innenfor maskinlæring for å analysere store samlinger av tekst systematisk. Samtidig er ikke alltid digitaliserte politiske tekster tilrettelagt for å analysers direkte. I disse tilfellene er god strukturering av rådata viktig.

Gjennom å delta i dette kurset vil du lære å søke i store mengder dokumenter, oppsummere disse på meningsfulle måter og indentifisere riktige analysemetoder for å teste statsvitenskaplige teorier med store tekstdata. Kurset vil dekke samling av store volum tekst fra offentlige kilder, strukturering og klargjøring av tekst for analyse og kvantitative tekstanalysemetoder.

1.2 Oppbygging av arbeidsboken

Denne arbeidsboken er ment som supplement til pensum i kurset forøvrig. Her vil vi gå gjennom de ulike delene av kurset, og spesielt legge oss tett opp til seminarundervisningen.

Under vil vi gå gjennom undervisningsopplegget, som arbeidsboken er lagt opp etter. Delene av boken er strukturert som følgende:

  1. Anskaffelse av tekst
  2. Laste inn eksisterende tekstkilder
  3. Forbehandling av tekst (preprosessering)
  4. Veiledet læring (supervised)
  5. Ikke-veiledet læring (unsupervised)
  6. Ordbøker
  7. Tekststatistikk
  8. Sentiment
  9. Temamodellering
  10. Latente posisjoner i tekst

1.2.1 Nødvendige pakker

Vi kommer til å bruke noen pakker gjennom kurset, som det kan være lurt å lære seg litt ekstra godt. Disse pakkene er:

Pakkenavn Beskrivelse
tidyverse Inneholder pakker som dplyr, ggplot2, stringr, med mer. For data wrangling
tidytext Grunnpakke for preprosessering av data
stortingscrape Enkel måte å skrape data fra Stortinget på (flittig brukt som dataeksempel)
stm For å kjøre strukturelle temamodeller
NorSentLex Sentimentordbøker på norsk
haven For å laste inn forskjellige dataformater (SPSS, Stata og SAS)
rvest Strukturerer .html/.xml

1.3 Anbefalte forberedelser

Siden kurset krever noe forkunnskap om R og generell metodisk kompetanse, anbefaler vi å se over følgende materiale før kurset starter:

2 Undervisning

Undervisningen i STV2022 består av 10 forelesninger og 5 seminarer. Vi vil bruke forelesningene til å oppsummere hovedkonseptene i hver ukes tema, både metodisk og anvendt. Seminarene vil ha hovedfokus på teknisk gjennomføring av tekstanalyse i R. Hvert seminar vil være delt i to med én del der seminarleder går gjennom ekstempler på kodeimplementering og én del der studentene kan jobbe med semesteroppgaven. Det er også verdt å merke seg at mange av implementeringene i kurset krever en del prøving og feiling.

Etter hvert seminar skal du levere et utkast av oppgaven for temaet man har gått gjennom i seminaret. Disse delene må bestås for å få vurdert semesteroppgave.

2.1 Forelesninger

De ti forelesningene har følgende timeplan (høsten 2022):

Dato Tid Aktivitet Sted Foreleser Ressurser/pensum
ti. 23. aug. 10:15–12:00 Introduksjon ES, Aud. 5 S. Bjørkholt og M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 1-2 og 22, Lucas et al. (2015), Silge and Robinson (2017) kap. 1, Pang, Lee, et al. (2008) kap. 1
ti. 30. aug. 10:15–12:00 Anskaffelse og innlasting av tekst ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 3-4, Cooksey (2014) kap. 1, Wickham (2020), Høyland and Søyland (2019)
ti. 6. sep. 10:15–12:00 Forbehandling av tekst 1 ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 5, Silge and Robinson (2017) kap. 3, Jørgensen et al. (2019), Barnes et al. (2019), Benoit and Matsuo (2020)
ti. 13. sep. 10:15–12:00 Forbehandling av tekst 2 ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 9, Silge and Robinson (2017) kap. 4, Denny and Spirling (2018)
ti. 20. sep. 10:15–12:00 Bruke API – Case: Stortinget ES, Aud. 5 M. Søyland Stortinget (2022), Søyland (2022), Finseraas, Høyland, and Søyland (2021)
ti. 11. okt. 10:15–12:00 Veiledet og ikke-veiledet læring ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 10 og 17, D’Orazio et al. (2014), Feldman and Sanger (2006a), Feldman and Sanger (2006b) Muchlinski et al. (2016)
ti. 18. okt. 10:15–12:00 Ordbøker, tekstlikhet og sentiment ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 7 og 16, Silge and Robinson (2017) kap. 2, Pang, Lee, et al. (2008) kap. 3-4, Liu (2015), Liu2015a
ti. 25. okt. 10:15–12:00 Temamodellering ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 13, Blei (2012), Silge and Robinson (2017) kap. 6, Roberts et al. (2014)
ti. 1. nov. 10:15–12:00 Estimere latent posisjon fra tekst ES, Aud. 5 S. Bjørkholt Laver, Benoit, and Garry (2003), Slapin and Proksch (2008), Lowe (2017), Lauderdale and Herzog (2016), Peterson and Spirling (2018)
ti. 15. nov. 10:15–12:00 Oppsummering ES, Aud. 5 S. Bjørkholt og M. Søyland Grimmer, Roberts, and Stewart (2022) kap 28, Wilkerson and Casas (2017)

2.2 Seminarer

I seminarene vil vi jobbe med en kombinasjon av kodeløsning for temaer fra forelesning og de forskjellige delene av semesteroppaven. Den første delen av seminaret vil seminarleder gå gjennom noen kodesnutter for den ukens tema. Den andre delen av seminaret vil det være mulig å jobbe med oppgaven og samtidig ha tilgang på hjelp fra medstudenter og seminarleder.

Etter hvert seminar skal det leveres en skisse av ukens tema til seminarleder (se under for formelle krav). Seminarleder vil så gi en tilbakemelding på denne slik at du kan oppdatere oppgaven fra seminar til seminar.

Uke Aktivitet
36 Seminar 1: Anskaffe tekst og lage dtm i R
38 Seminar 2: Preprosessering av tekstdata i R
42 Seminar 3: Veiledet og ikke-veiledet læring i R
44 Seminar 4: Modelleringsmetoder i R
46 Seminar 5: Fra tekst til funn, Q&A og oppgavehjelp

Seminarledere:

2.3 Oppgaver

Evalueringsformen for STV2022 er en semesteroppgave som man jobber med kontinuerlig over hele semesteret. Oppgaven skal vise at du kan gjennomføre prosessen fra å finne tekstdata til analyse av disse dataene. Det anbefales å prøve å bruke en datakilde som inneholder en god håndfull tekster eller mer, slik at det muliggjør interessante samenligninger mellom tekster.

Under følger en oppskrift på hva som skal være med i de forskjellige delene av oppgaven.

2.3.1 Uke 36 – Anskaffe tekst

  1. Skissér en hypotese basert på eksisterende teorier
  2. Finn en datakilde du tenker kan brukes til å svare på hypotesen din
  3. Hent og strukturer data
  4. Gi en kort beskrivelse av hvordan dataene ble fanget og hvordan de er strukturert

2.3.2 Uke 38 – Preprosessering av tekstdata i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Gjør nødvendige preprosesseringsgrep for å redusere/standardisere dataene dine
  3. Visualiser forskjellen mellom tekstene før og etter preprosessering
  4. Diskuter preprosesseringen kritisk

2.3.3 Uke 42 – Veiledet og ikke-veiledet læring i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Identifiser en analysestrategi for dine data
  3. Diskuter fordeler og ulemper med din strategi

2.3.4 Uke 44 – Modelleringsmetoder i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Velg hvilke(n) analysemetode(r) du vil bruke for å analysere data
  3. Kjør analysene
  4. Tolk resultatene og implikasjonene av det du har funnet

2.3.5 Uke 46 – Siste utkast

  1. Rediger oppaven basert på tilbakemeldinger fra de forrige ukene

2.3.6 Formelle krav

  • Skisser til seminar
    1. Følg oppskriften for seminargangen
      • For eksempel, skal du, etter seminar i uke 36, levere en skisse som inneholder delene som beskrives i oppskriften for uke 36
    2. Oppgaven leveres senest kl. 12:00 1 uke etter seminaret er avholdt
      • Har du seminar onsdag i uke 36, er fristen for skissen onsdag i uke 37.
    3. Seminarleder gir tilbakemelding på skissen din og du reviderer oppgaven deretter
    4. Til neste seminar går du tilbake til punkt 1 og jobber deg gjennom lista igjen
  • Den endelige semesteroppgaven…
    1. følger oppskriften over og inneholder…
      • … introduksjon
      • … teoribasert hypotese
      • … beskrivelse av data og datafangst
      • … kritisk diskusjon om preprosesseringen
      • … diskusjon rundt valgt analysestrategi
      • … resultat, tolkning og implikasjoner av analysen
      • … konklusjon/oppsummering
    2. … skal være mellom 3000 og 4000 ord (eksludert referanser)
    3. … leveres i .pdf-format på Inspera
    4. … har et kjørbart .R-script som reproduserer resultatene i oppgaven vedlagt

2.4 Pensum

Som med alle andre fag, er det sterkt anbefalt at man ser over pensum før forelesning og seminar. Likevel kan pensum i kurset til tider være noe teknisk og uhåndterbart. Det er ikke forventet å pugge formler eller fult ut forstå de matematiske beregninger bak de forskjellige modelleringsmetodene (selv om det åpenbart kan gjøre stoffet lettere å forstå). Hovedfokuset vårt vil være på å forstå hvilke operasjoner man må gjøre for å gå fra tekst til funn, hvilke antagelser man gjør i prosessen og klare å velge de riktige modellene for spørsmålet man vil ha svar på.

Grunnboken i pensum er Grimmer, Roberts, and Stewart (2022). Vi vil lene oss mye på denne over alle temaene vi gjennomgår. For R har vi valgt å gjøre materialet så standardisert som mulig ved å bruke tidyverse så langt det lar seg gjøre. Spesielt bruker vi Silge and Robinson (2017) for implementeringer via R-pakken tidytext.

Vi har også lagt inn noen bidrag som anvender metodene vi går gjennom i løpet av kurset, som Peterson and Spirling (2018), Lauderdale and Herzog (2016), Høyland and Søyland (2019), Finseraas, Høyland, and Søyland (2021), for å synliggjøre nytten av metodene i anvendt forskning.

3 Laste inn tekstdata

I denne delen av arbeidsboken vil vi gå gjennom noen eksempler på hvordan vi kan laste inn tekstdata i R.

Tekstdata kan komme i uendelig mange forskjellige formater, og det er umulig å gå gjennom alle. Vi har likevel noen typer data som er mer vanlig innenfor statsvitenskap enn andre. Under vil vi gå gjennom 1) lasting av ulike to-dimensjonale datasett (.rda/.Rdata, .csv, .sav og .dta), 2) rå tekstfiler (.txt), 3) tekstfiler med overhead (.pdf og .docx).

3.1 To-dimensjonale datasett

Det vanligste formatet på eksisterende data innenfor politisk analyse er to-dimensjonale datasett. Et datasett består av rader (vanligvis observasjoner/enheter) og kolonner (vanligvis variabler). Disse datasettene kommer i mange forskjellige format, men de aller fleste (eller alle) kan leses inn i R om man finner de rette funksjonene.

Under vil vi illustre de forskjellige måtene å laste inn data på med eksempeldata fra pakken stortingscrape, som inneholder metadata på alle saker Stortinget behandlet i 2019-2020-sesjonen:

## 
library(stortingscrape)
#saker <- cases$root

saker %>% 
  select(id, document_group, status, title_short) %>% 
  mutate(title_short = str_sub(title_short, 1, 30)) %>% 
  tail()
##        id      document_group         status                    title_short
## 609 77122        redegjorelse      behandlet                 Trontaledebatt
## 610 78034      dokumentserien      behandlet Spørsmål til skriftlig besvare
## 611 81959    grunnlovsforslag        mottatt Grunnlovsforslag fremsatt på d
## 612 76618    grunnlovsforslag til_behandling Grunnlovsforslag om endring i 
## 613 76114      dokumentserien      behandlet Riksrevisjonens undersøkelse a
## 614 74133 representantforslag       bortfalt Representantforslag om en lov

3.1.1 .rda og .Rdata

R har sin egen type filformat med filtypene .rda og .Rdata (.Rds finnes også, men vi hopper over det her). Disse to formatene er faktisk akkurat det samme formatet; .rda er bare en forkortelse for .Rdata. Disse filene er komprimerte versjoner av objekter i Environment, som man kan lagre lokalt. Fordi denne filtypen har veldig god kompresjon og selvfølgelig virker sømløst sammen med R, er det et veldig nyttig format å bruke. Dette gjelder særlig når man jobber med store tekstdata.

Som eksempel på lagring kan jeg trekke ut data fra stortingscrape-pakken og lagre disse lokalt med save()-funksjonen:

save(saker, file = "./data/saker.rda")

Om man har flere objekter i Environment man vil lagre samtidig som .rda / .Rdata, er dette mulig å gjøre med funksjonen save.image().

For å laste inn .rda / .Rdata bruker man funksjonen load():

load("./data/saker.rda")

En ting som ofte er litt forvirrende, er at filnavnet til .rda ikke nødvendigvis samsvarer med navnet man får opp på objektene i R; objektene i Environment vil alltid ha samme navn som de hadde i Environment når filen ble lagret.

3.1.2 .csv

Et veldig enkelt og vanlig format for å distribuere data, er kommaseparerte filer (.csv). Man kan enkelt lese inn .csv-filer med read.csv(), eller, som vist under, med funksjonen read_csv() fra pakken readr.1

library(readr)

saker <- read_csv("./data/saker.csv", show_col_types = FALSE)

Argumentet show_col_types fjerner en beskjed om hvordan data blir lastet inn. Dette kan noen ganger være nyttig å se dette, men det blir fort litt clutter av det.

3.1.3 .sav (SPSS) og .dta (Stata)

For å lese inn filer som er lagret i SPSS, bruker vi pakken haven som har flere fuksjoner for å lese diverse dataformat (SAS, Stata (se under) og SPSS). Pakken følger standard syntaks for innlesing av data:

library(haven)
saker <- read_sav("./data/saker.sav")

For Stata (.dta) er det helt lik syntaks, bare nå med funksjonen read_dta():

saker <- read_dta("./data/saker.dta")

Merk at både SPSS- og Stata-filer kan komme med labels på variablene i datasettet. Dette kan noen ganger fungere som en kodebok.

3.2 Rå tekstfiler (.txt)

Rå tekstfiler (.txt) er et veldig fint format å jobbe med når man jobber med tekst. Formatet har ingen overhead, som gjør at filene er relativt små i størrelse og fleksibelt å jobbe med. En vanlig måte å strukturere .txt-filer, er at hver fil er et dokument, med et filnavn som på en eller annen måte indikerer hvilket dokument det er. Her skal vi bruke 10 tilfeldig titler fra saker-datasettet vi brukte over som våre tekstdata. Hver fil er navngitt med tilsvarende id fra datasettet.

Vi lister opp filene som er i mappen data/txt og leser inn hver fil som et listeelement:

filer <- list.files("./data/txt", pattern = ".txt", full.names = TRUE)
filer
##  [1] "./data/txt/74133.txt" "./data/txt/76404.txt" "./data/txt/76632.txt"
##  [4] "./data/txt/77394.txt" "./data/txt/78215.txt" "./data/txt/79201.txt"
##  [7] "./data/txt/79389.txt" "./data/txt/79667.txt" "./data/txt/80260.txt"
## [10] "./data/txt/81958.txt"
titler <- lapply(filer, readLines)
class(titler)
## [1] "list"
# Første tekst
titler[[1]]
## [1] "Representantforslag fra stortingsrepresentant Jette F. Christensen om en lov mot moderne slaveri"

Hvis man vil gå rett over til et datasett, kan vi navngi listeelementene ved å trekke ut id fra filnavnene:

names(titler) <- str_extract(filer, "[0-9]+")
names(titler)
##  [1] "74133" "76404" "76632" "77394" "78215" "79201" "79389" "79667" "80260"
## [10] "81958"

Deretter kan vi enkelt gjøre om tekstene til en vektor med unlist() og putte det inn i en data.frame() sammen med en id variabel, som vi henter fra navnene i lista:

saker_txt <- data.frame(titler = unlist(titler),
                        id = names(titler))

For å illustere at dette ble riktig, kan vi merge saker med saker_txt, og se om variabelen titler er den samme som variabelen title:

saker_merge <- left_join(saker_txt, saker[, c("id", "title")], by = "id")

saker_merge$titler == saker_merge$title
##  [1]  TRUE FALSE FALSE  TRUE FALSE FALSE  TRUE FALSE FALSE FALSE

Det kan likevel være lurt å jobbe litt med dataene i listeformat før man går over til datasett, om man jobber med veldig store korpus. Lister krever litt mindre minne og kan ofte være litt mer effektivt å jobbe med gjennom funksjoner som sapply(), lapply() og mclapply()

3.3 Tekstfiler med overhead

En .txt-fil er som den er; det er ingen sjulte datakilder i slike filer. Det er det derimot i andre filformater. En MS Word-fil, for eksempel, er egentlig bare et komprimert arkiv (.zip) med underliggende html / xml som bestemmer hvordan filen skal se ut når du åpner den i MS Word. Vi bruker det siste MS Word-dokumentet Martin skrev (bacheloroppgave fra 2013) som eksempel:

unzip("data/ba_thesis.docx", exdir = "data/wordfiles")

list.files("data/wordfiles/")
## [1] "[Content_Types].xml" "_rels"               "customXml"          
## [4] "docProps"            "word"

Dette gjør at disse filene er mye vanskeligere å lese inn i R enn rå tekstfiler, og vi får veldig rar output når vi bruker readLines():

readLines("./data/ba_thesis.docx", n = 2)
## Warning in readLines("./data/ba_thesis.docx", n = 2): line 1 appears to contain
## an embedded nul
## Warning in readLines("./data/ba_thesis.docx", n = 2): incomplete final line
## found on './data/ba_thesis.docx'
## [1] "PK\003\004\024"

Derfor vil det kreve andre metoder for å lese inn filer med overhead. Under eksemplifiserer vi med .docx og .pdf, som er de mest brukte av denne type filer.

3.3.1 .docx

Heldigvis har andre laget løsninger for oss på dette også. Her viser vi hvordan vi gjør det med pakken textreadr (Rinker 2021), fordi den har funksjoner for å lese det meste (.doc, .docx, .pdf, .odt, .pptx, osv):

library(textreadr)

ba_docx <- read_docx("./data/ba_thesis.docx")

ba_docx[43:46]
## [1] "Three hypotheses are derived from the question:"                                            
## [2] "H0: There is no relationship between secrecy jurisdiction status and quality of governance."
## [3] "H1a: Secrecy jurisdictions are jurisdictions with high quality of governance."              
## [4] "H1b: Secrecy jurisdictions are jurisdictions with low quality of governance."

Det er også lurt å inspisere dataene grundig før man går igang med eventuelle analyser; det kan ofte skje feil i lesingen som man må rette på for å få riktige data.

3.3.2 .pdf

Det samme gjelder for .pdf-filer:

ba_pdf <- read_pdf("./data/ba_thesis.pdf")

ba_pdf <- ba_pdf$text[4] %>% 
  strsplit("\\n") %>% 
  unlist()

ba_pdf[11:14]
## [1] "    1.2     Hypothesis"                      
## [2] "The overlying question of the study will be:"
## [3] ""                                            
## [4] ""

Her ble outputen av read_pdf() delt inn i sider, i tillegg til at teksten ikke ble delt opp i linjer. Så vi har gått inn og tatt ut side 4, delt opp teksten i linjer og trukket ut tilsvarende linjer som vi gjorde i MS Word-filen.

La oss også nevne at endel (spesielt historiske) dokumenter i .pdf-format er scannet og bare inneholder bilder av tekst – ikke tekst man enkelt kan ta ut av dokumentet. Da må man ty til Optical Character Recognition (OCR), noe vi dessverre ikke kommer til å gå gjennom i dette kurset.

4 Anskaffelse av tekst

4.1 .html-skraping

Internett er en fantastisk kilde til informasjon, og derfor også en veldig god måte å anskaffe data på. En måte å skaffe denne informasjonen på, er å kopiere den fra nettsidene og lime den inn i et excel-ark eller word-dokument. Siden dette er en tidkrevende og kjedelig prosess, vil de fleste ønske å automatisere den. Det er dette som er skraping. Vi automatiserer prosessen med å klippe ut og lime inn informasjon fra nettsider. Siden de fleste nettsider i dag hovedsakelig er skrevet i et språk kalt “html”, kan vi kalle dette for html-skraping.

All html-kode ligger åpent tilgjengelig for alle. For å finne den, åpne en nettside, høyreklikk på siden og velg “Inspect”. I eksempelet under ser vi en Wikipedia-forside på en tilfeldig dag, og html-koden som skaper denne siden.

All html-kode er hierarkisk. Egentlig likner den veldig på et familietre. I toppen har vi familiens overhode, <html>-noden. Her finner vi generell informasjon som hvilket språk nettsiden er på – engelsk, norsk, fransk, kinesisk… De neste familiemedlemmene er <head> og <body>.

  • <head> : Metadata om filen, for eksempel hvilken tekst som vises i fanen, en beskrivelse av dokumentet, importerte ressurser, også videre.
  • <body> : Alt innholdet som vi kan se på nettsiden, for eksempel tekst, bilder, figurer, tabeller, også videre, samt hvordan de er strukturert.

Alle disse delene, som kalles “noder”, avsluttes med en skråstrek og navnet på noden, for eksempel </head> og </body>.

<head> og <body> er barn av noden <html>. Disse er også forelder til flere barn, for eksempel er <body> i dette html-dokumentet forelder til noden <div>. <div> angir et spesielt område i dokumentet. Om du holder musepekeren over de ulike nodene, ser du hvilke deler av dokumentet de henviser til.

Noen eksempler på HTML-noder er:

  • <div> : Del av dokumentet
  • <section> : Seksjon av dokumentet
  • <table> : En tabell
  • <p> : Et avsnitt
  • <h2> : Overskrift i størrelse 2
  • <h6> : Overskrift i størrelse 6
  • <a> : Hyperlenke som refererer til andre nettsider gjennom href
  • <img> : Et bilde
  • <br> : Avstand mellom avsnitt

4.1.1 Hvordan skrape en nettside

Vi bruker R-pakken rvest for å skrape. For å laste inn en pakke bruker vi library. Om du ikke har installert den før, må du gjøre dette med install.packages("rvest") (husk gåsetegnene når man installerer pakker).

library(rvest)

Når vi skraper en nettside, er det fem steg vi må gjennom:

  1. I RStudio, skriv read_html og sett som argument addressen eller filstien til nettsiden du vil hente informasjon fra.
  2. “Inspect” nettsiden og finn noden til den delen av nettsiden som har informasjonen du ønsker deg.
  3. Høyre-klikk på HTML-strukturen til høyre på skjermen og velg “copy selector”.
  4. Gå tilbake til RStudio. I html_node spesifiserer du den relevante noden ved å lime inn det du kopierte i forrige steg.
  5. Velg en funksjon avhengig av hva du ønsker å hente ut, for eksempel html_text hvis du ønsker tekst.

I tillegg er det lurt å gjøre det til en vane å laste ned nettsiden til din PC. Dette vil hjelpe på flere måter:

  • Det gjør presset på serveren mindre ettersom du bare laster ned nettsiden én gang.
  • Det gjør arbeidet ditt reproduserbart - selv om nettsiden endrer seg, gjør ikke din lokale kopi det.
  • Det gjør at du kan nå disse filene selv uten at du har internett.

For å laste ned en html-fil kan du bruke download.file og sette som argument URL-addressen til nettsiden. Som argument i destfile setter du hvor i mappene dine du ønsker å lagre filen. I eksempel under laster jeg ned Wikipedia-artikkelen om appelsiner.

download.file("https://en.wikipedia.org/wiki/Orange_(fruit)", # Last ned en html-fil ...
                destfile = "./data/links/Oranges.html") # ... inn i en spesifikk mappe

# Hvis du har mac, må du sette tilde (~) istedenfor punktum (.)
# Husk å være oppmerksom på hvor du har working directory, sjekk med getwd() og sett nytt working directory med setwd()

Vi leser inn nettsiden til R med read_html. Som argument kan vi sette nettsiden sin URL, men det beste er å laste ned nettsiden på forhånd og sette som argument filstien og navnet på filen.

library(rvest)

## read_html("https://en.wikipedia.org/wiki/Orange_(fruit)") # Les inn direkte fra nettside

read_html("./data/links/Oranges.html") # Les inn fra din nedlastede fil
## {html_document}
## <html class="client-nojs" lang="en" dir="ltr">
## [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
## [2] <body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject  ...

4.1.1.1 Tekst

La oss si vi ønsker oss tekst fra nettsiden. Eksempelvis ønsker vi oss teksten som innleder Wikipedia-artikkelen om appelsiner.

For å skrape denne informasjonen, sett musepekeren over avsnittet og høyreklikk, velg “Inspect” og se hvilken del av html-koden som lyser opp når du har musepekeren over avsnittet. Vi ser at det er en <p>-node som inneholder denne teksen. For å finne den fulle html-noden:

  1. Høyreklikk på noden.
  2. Velg “Copy”.
  3. Velg “Copy selector”.

Lim inn dette under html_node. Videre, siden vi ønsker oss tekst, velg html_text. For å ta ut whitespace kan vi sette trim = TRUE.

read_html("./data/links/Oranges.html") %>%
  html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
  html_text(trim = TRUE)
## [1] "An orange is a fruit of various citrus species in the family Rutaceae (see list of plants known as orange); it primarily refers to Citrus × sinensis,[1] which is also called sweet orange, to distinguish it from the related Citrus × aurantium, referred to as bitter orange. The sweet orange reproduces asexually (apomixis through nucellar embryony); varieties of sweet orange arise through mutations.[2][3][4][5]"

4.1.1.2 Tabeller

Tabeller er også typisk nokså enkle å hente fra nettsider. De befinner seg gjerne i html-noder kalt <table> og <tbody>.

Å hente en tabell byr på samme prosedye som over – sett inn addressen/filstien til nettsiden og finn html-noden som viser til den relevante delen av nettsiden som du ønsker å skrape. Istedenfor å velge html_text velger du da html_table.

read_html("./data/links/Oranges.html") %>%
  html_node("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
  html_table()
## # A tibble: 42 x 2
##    `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
##    <chr>                                  <chr>                                 
##  1 "Energy"                               "197 kJ (47 kcal)"                    
##  2 ""                                     ""                                    
##  3 "Carbohydrates"                        "11.75 g"                             
##  4 "Sugars"                               "9.35 g"                              
##  5 "Dietary fiber"                        "2.4 g"                               
##  6 ""                                     ""                                    
##  7 ""                                     ""                                    
##  8 "Fat"                                  "0.12 g"                              
##  9 ""                                     ""                                    
## 10 ""                                     ""                                    
## # ... with 32 more rows

Vi kan i tillegg rydde litt opp i koden for å få en penere tabell.

read_html("./data/links/Oranges.html") %>%
  html_node("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
  html_table() %>%
  na_if("") %>% # Erstatter "" med NA (missing)
  na.omit() # Fjerner alle NA
## # A tibble: 30 x 2
##    `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
##    <chr>                                  <chr>                                 
##  1 Energy                                 197 kJ (47 kcal)                      
##  2 Carbohydrates                          11.75 g                               
##  3 Sugars                                 9.35 g                                
##  4 Dietary fiber                          2.4 g                                 
##  5 Fat                                    0.12 g                                
##  6 Protein                                0.94 g                                
##  7 Vitamins                               Quantity %DV†                         
##  8 Vitamin A equiv.                       1% 11 µg                              
##  9 Thiamine (B1)                          8% 0.087 mg                           
## 10 Riboflavin (B2)                        3% 0.04 mg                            
## # ... with 20 more rows

4.1.1.3 Lenker

Internett er proppfullt av lenker. Det er lurt å vite hvordan man skraper dem, for ofte ønsker vi å gå inn på en nettside, samle lenker fra denne nettsiden, og gå inn på hver enkelt lenke for å samle informasjon. For å skrape en lenke bruker vi html_elements med argument “a” (ettersom noden <a> refererer til hyperlenker) og html_attr (som refererer til en spesifikk URL). Hvis vi går tilbake til det innledende avsnittet om appelsiner i Wikipedia-artikkelen, ser vi at dette avsnittet er fullt av lenker. For å samle disse kan vi bruke koden under:

read_html("./data/links/Oranges.html") %>%
  html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
  html_elements("a") %>%
  html_attr("href")

For å få fullstendige lenker, må hente ut de lenkene vi tenker å bruke og lime på første halvdel av URL-en. Dette kan vi gjøre med str_extract og str_c.

links <- read_html("./data/links/Oranges.html") %>%
  html_node("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
  html_elements("a") %>%
  html_attr("href") %>%
  str_extract("/wiki.*") %>% # Samle bare de URL-ene som starter med "/wiki", fulgt av hva som helst (.*)
  na.omit() %>% # Alle andre strenger blir NA, vi fjerner disse
  str_c("https://en.wikipedia.org/", .) # str_c limer sammen to strenger, vi limer på første halvdel av URL-en.

Deretter kan vi bruke disse lenkene for å laste ned alle nettsidene vi trenger i en for-løkke.

linkstopic <- str_remove(links, "https://en.wikipedia.org//wiki/")

for(i in 1:length(links)) { # For alle lenkene...
  
  download.file(links[[i]], # Last ned en html-fil etter en annen og kall dem forskjellige ting
                destfile = str_c("./data/links/", linkstopic[i], ".html"))
}

Deretter kan vi lage en for-løkke for å laste inn testen fra alle nettsidene i folderen.

fruit_files <- list.files("./data/links", full.names = TRUE) # Liste med filene vi har lastet ned

info <- list() # Lag et liste-objekt hvor du kan putte output fra løkken

for (i in 1:length(fruit_files)) { # For hver enhet (i) som finnes i links, fra plass 1 til sisteplass i objektet (gitt med length(links))...
  
  page <- read_html(fruit_files[i]) # ... les html-filen for hver i
  
  page <- page %>% # Bruk denne siden
    html_elements("p") %>% # Og få tak i avsnittene
    html_text() # Deretter, hent ut teksten fra disse avsnittene
  
  info[[i]] <- page # Plasser teksten inn på sin respektive plass i info-objektet
  
}

# Info-objektet inneholder nå blant annet:

info[[1]][3]
## [1] "In flowering plants, the term \"apomixis\" is commonly used in a restricted sense to mean agamospermy, i.e., clonal reproduction through seeds. Although agamospermy could theoretically occur in gymnosperms, it appears to be absent in that group.[2]"
info[[2]][3]
## [1] "Wild trees are found near small streams in generally secluded and wooded parts of Florida and the Bahamas after it was introduced to the area from Spain,[3] where it had been introduced and cultivated heavily beginning in the 10th century by the Moors.[4][5]"
info[[3]][2]
## [1] "\r\n"

4.2 Andre formater og APIer

Selv om nettsider i .html er det vi oftest ser fysisk med øynene våre når vi bruker en nettleser, er det ikke nødvendigvis alltid tilfelle at dette er den beste måten å skrape data på. Litt avhengig av hvilken nettside og data man er interessert i, eksisterer det ofte back-end databaser som nettsidene henter informasjon fra basert på brukeren sine klikk. Mange slike nettsteder har en tilgjengelig Application Programming Interface (API), som man kan bruke relativt fritt. Og noen nettsider er i seg selv en API. Ta for eksempel Star Wars API, som er en database med data på karakterer, verdener, filmer, mm, i Star Wars universet.

Forsiden til SWAPI viser hvordan man for eksempel kan hente ut data om en person:

## 
## person1_url <- "https://swapi.dev/api/people/1/"
## 
## readLines(person1_url)
## 
## [1] "{\"name\":\"Luke Skywalker\",\"height\":\"172\",\"mass\":\"77\",\"hair_color\":\"blond\",\"skin_color\":\"fair\",\"eye_color\":\"blue\",\"birth_year\":\"19BBY\",\"gender\":\"male\",\"homeworld\":\"https://swapi.dev/api/planets/1/\",\"films\":[\"https://swapi.dev/api/films/1/\",\"https://swapi.dev/api/films/2/\",\"https://swapi.dev/api/films/3/\",\"https://swapi.dev/api/films/6/\"],\"species\":[],\"vehicles\":[\"https://swapi.dev/api/vehicles/14/\",\"https://swapi.dev/api/vehicles/30/\"],\"starships\":[\"https://swapi.dev/api/starships/12/\",\"https://swapi.dev/api/starships/22/\"],\"created\":\"2014-12-09T13:50:51.644000Z\",\"edited\":\"2014-12-20T21:17:56.891000Z\",\"url\":\"https://swapi.dev/api/people/1/\"}"

4.2.1 .json

Her ser dataformatet veldig annerledes ut enn en .html fordi .html er en dårlig måte å lagre data på. De aller fleste APIer bruker heller formater som .xml og .json. I SWAPI sitt tilfelle, får vi ut data i .json-format. Dette formatet egner seg ikke kjempegodt å lese med readLines(). Men, som alltid, har noen laget en pakke som parser data i .json for oss:

library(jsonlite)

person1 <- read_json("./data/swapi/person1.json")

names(person1)
##  [1] "name"       "height"     "mass"       "hair_color" "skin_color"
##  [6] "eye_color"  "birth_year" "gender"     "homeworld"  "films"     
## [11] "species"    "vehicles"   "starships"  "created"    "edited"    
## [16] "url"
class(person1)
## [1] "list"
person1$name
## [1] "Luke Skywalker"
person1$starships
## [[1]]
## [1] "https://swapi.dev/api/starships/12/"
## 
## [[2]]
## [1] "https://swapi.dev/api/starships/22/"

Elementer som starships, homeworld ogfilms linker videre til andre deler av APIet, som man kan trekke ut videre data fra om det er ønskelig

Under finner du et litt lenger eksempel på en potensiell workflow for SWAPI, som det går an å eksperimentere med:

#################################################
### SWAPI som eksempel for .json-skraping i R ###
#################################################

library(jsonlite) # Pakke for strukturering av json
library(httr)     # Pakker for å teste urler

# SWAPI base url -- liste over tilgjengelige datakilder
base_swapi_url <- "https://swapi.dev/api/"

# Laster ned datakildeliste
swapi_base <- read_json(base_swapi_url)

# Ser hvilke elementer som er i lista
names(swapi_base)

# Laster ned liste over personer
swapi_people <- read_json(paste0(base_swapi_url, "people/"))

# Sjekker struktur på personer
# listviewer::jsonedit(swapi_people)

# Ser at det er 82 personer i "count"
swapi_people$count

# Lager en tom liste
swapi_people_individuals <- list()

# Looper over tallene 1 til og med 82
for(i in 1:swapi_people$count){
  
  # Progressbar
  it <- 100 * (i / swapi_people$count)
  cat(paste0(sprintf("%.2f%%         ", it), "\r"))
  
  # Tester url (f.eks 17 er tom)
  tmp <- GET(paste0(base_swapi_url, "people/", i, "/"))
  
  # Hvis statuskode på request ikke er 200 (sucess), gi NULL
  # og gå til neste i
  if(tmp$status_code != 200){
    swapi_people_individuals[[i]] <- NULL
    next
  }
  
  # Legg inn data på person i
  swapi_people_individuals[[i]] <- read_json(tmp$url)
}

# Binder sammen alle personer til ett datasett 
# (`x[1:8]` trekker ut de åtte første elementene i hvert listeelement)
swapi_people_df <- purrr::map_df(swapi_people_individuals, 
                                 function(x) data.frame(x[1:8]))

# Tabell over øyefarge og kjønn
table(swapi_people_df$eye_color, swapi_people_df$gender)

Et lite tips, om man jobber med vedlig uoversiktelige .json-filer, er å bruke listviewer-pakken. Den gir et veldig oversiktelig visuelt tre av dataene.

4.2.2 .xml

Det andre dataformatet som er mest vanlig i APIer er .xml. Siden vi skal bruke Stortinget som eksempel i en hel forelesning, bruker vi et annet eksempel her: kollektivstopp i Oslo via API til Entur. .xml er ganske likt .html, bare lettere å jobbe med (stort sett).

Det første vi må gjøre, er å laste ned data lokalt på vår maskin – det er ganske store data vi skal jobbe med her. Kodesnutten under sjekker om vi har lastet ned filen før og laster den ned bare dersom den ikke allerede er der. Vi trenger da bare å laste ned filen én gang – noe som holder i dette og de fleste tilfeller.

if(file.exists("./data/ruter.xml") == FALSE){
  download.file(url = "https://api.entur.io/realtime/v1/rest/et?datasetId=RUT",
                destfile = "./data/ruter.xml")
}

Vi skal bruke deler av .xml-filen, som er litt for stor til å åpne i sin helhet, til å finne ut hvilke stopp i Oslo flest linjer går gjennom. Disse delene ser ut som dette:

# Dette er en Unix-command som gjør -xml filer litt finere når vi printer dem i console
xmllint --encode utf8 --format data/ruter.xml | sed -n 1185,1247p
<RecordedCalls>
  <RecordedCall>
    <StopPointRef>NSR:Quay:8107</StopPointRef>
    <Order>1</Order>
    <StopPointName>Lillestrøm bussterminal</StopPointName>
    <AimedDepartureTime>2022-08-03T13:50:00+02:00</AimedDepartureTime>
    <ActualDepartureTime>2022-08-03T13:50:00+02:00</ActualDepartureTime>
  </RecordedCall>
  <RecordedCall>
    <StopPointRef>NSR:Quay:9371</StopPointRef>
    <Order>2</Order>
    <StopPointName>Eikeliveien</StopPointName>
    <AimedArrivalTime>2022-08-03T13:52:00+02:00</AimedArrivalTime>
    <ActualArrivalTime>2022-08-03T13:52:00+02:00</ActualArrivalTime>
    <AimedDepartureTime>2022-08-03T13:52:00+02:00</AimedDepartureTime>
    <ActualDepartureTime>2022-08-03T13:52:00+02:00</ActualDepartureTime>
  </RecordedCall>
  . . .
</RecordedCalls>

Det ligner litt på .html i skrivemåte, men er veldig mye mer strukturert.

Det neste vi må gjøre er å lese den lokale .xml filen. Det gjør vi med samme funksjon som vi bruke på front-end .html-sider: rvest::read_html():

library(rvest)

ruter <- read_html("./data/ruter.xml")

Nå står vi fritt til å trekke ut de dataene vi ønsker fra filen. I vårt tilfelle skal vi ha ut alle stopp på alle kollektivruter i Oslo. Disse finnes innenfor <recordedcall> . . . </recordedcall>. Koden under kan nok virke litt avansert med første øyekast, men et tips for å se hva som skjer inni funksjonen kan være å lage objektet x som det første listeelementet i stopp2, for så å kjøre hver linje inni funksjonen bare på dette elementet

# Deler opp .xml-dokumentet i hver del som er innenfor 
# <recordedcall> . . . </recordedcall
stopp <- ruter %>% html_elements("recordedcall")

# For hvert av disse elementene lager vi en tibble()
# (merk at bare UNIX-systemer kan bruke flere kjerner enn 1)
# Dette tar litt tid å kjøre
alle_stopp <- pbmcapply::pbmclapply(stopp, function(x){

    
  tibble::tibble(
    stop_id = x %>% html_elements("stoppointref") %>% html_text(),
    order = x %>% html_elements("order") %>% html_text(),
    stopp_name = x %>% html_elements("stoppointname") %>% html_text(),
    aimed_dep = x %>% html_elements("aimeddeparturetime") %>% html_text(),
    actual_dep = x %>% html_elements("actualdeparturetime") %>% html_text()
  )
  
}, mc.cores = parallel::detectCores()-1)

alle_stopp <- bind_rows(alle_stopp)

Da har vi et datasett som vi kan bruke til å lage for eksempel en ordsky!

# Viser data
head(alle_stopp)
## # A tibble: 6 x 5
##   stop_id         order stopp_name              aimed_dep                actua~1
##   <chr>           <chr> <chr>                   <chr>                    <chr>  
## 1 NSR:Quay:8107   1     Lillestrøm bussterminal 2022-08-03T13:50:00+02:~ 2022-0~
## 2 NSR:Quay:9371   2     Eikeliveien             2022-08-03T13:52:00+02:~ 2022-0~
## 3 NSR:Quay:102425 3     Strømsdalen             2022-08-03T13:53:00+02:~ 2022-0~
## 4 NSR:Quay:9384   4     Øvre Strømsdal          2022-08-03T13:54:00+02:~ 2022-0~
## 5 NSR:Quay:9289   5     Furukollen              2022-08-03T13:55:00+02:~ 2022-0~
## 6 NSR:Quay:9352   6     Petrinehøy              2022-08-03T13:56:00+02:~ 2022-0~
## # ... with abbreviated variable name 1: actual_dep
# Lager nytt datasett der ... 
stop_name_count <- alle_stopp %>% 
  count(stopp_name) %>%             # vi teller stoppnavn
  arrange(desc(n)) %>%              # sorterer data etter # linjer
  filter(nchar(stopp_name) > 3) %>% # tar bort korte stoppnavn
  slice_max(n = 30, order_by = n)   # tar med bare de 30 mest brukte stoppene


library(ggwordcloud)

# Setter opp tilfeldige farger
cols <- sample(colors(),
               size = nrow(stop_name_count),
               replace = TRUE)

# Lager plot
stop_name_count %>% 
  ggplot(., aes(label = stopp_name, 
                size = n,  
                color = cols)) +
  geom_text_wordcloud_area()+
  scale_size_area(max_size = 10) +
  ggdark::dark_theme_void()

Som ventet, er Jernbanetorget-stoppet flest linjer går gjennom.

4.2.3 API-liste

Her er en liste over noen APIer med (stort sett) norske data:

Det er også verdt å merke seg at veldig mange nettsider som ikke har en åpen API, gjerne har en backend API der data hentes for å vise nettsiden til brukere av frontend. Dette kan man finne, men det er ikke alltid du har lov å bruke det (vi snakker mer om dette i forelesning [02] Anskaffelse og innlasting av tekst)

4.3 Litt om kravling

Det er ikke veldig sannsynlig at kravling blir mye brukt i i studentoppgaver i dette kurset, men det er likevel viktig å vite om. Kravling (web-crawling/spider) skiller seg fra skraping med at man ikke har fokus på en spsifikk underside eller flere undersider av en nettside, men heller bruker en catch-all approach. Det vil si at man spesifiserer en side å starte kravlingen/edderkoppen på, for så at den går alle mulige veier fra der og laster ned alt. Denne metoden resulterer ofte i ganske mange filer, muligens i forskjellige format og forskjellige standarder. Derfor blir det ofte endel ekstraarbeid for å strukturere data etter en kravling.

I R kan vi bruke pakken Rcrawler. Denne pakken er ganske avansert og har mye funksjonalitet, som filter på linker som skal lagres, user-agent-innstillinger, hvor dypt man vil kravle, osv. Under viser kode for å laste ned alle tekster fra Virksomme ord. Men se også forelesning [02] Anskaffelse og innlasting av tekst

# Laster inn pakke for kravling
library(Rcrawler)

Rcrawler("http://virksommeord.no/", # Nettsiden vi skal kravle
         DIR = "./crawl",           # mappen vi lagrer filene i
         no_cores = 4,              # kjerner for å prosessere data
         dataUrlfilter = "/tale/",  # subset filter for kravling
         RequestsDelay = 2 + abs(rnorm(1)))

5 Preprosessering

Når vi nå har lært både å laste inn eksisterende tekstdata og strukturere våre egne data via skraping, kan vi begynne å tenke på hvordan vi kan sammenligne tekstene i vårt korpus eller datasett. Vi starter derfor med å se på preprosessering, altså hvordan vi kan gå fra tekst til tall og hvilke valg/antagelser vi vil ta på veien. I denne delen av notatboken skal vi gå gjennom den mest grunnleggende antagelsen vi gjør i kvantitativ analyse av store tekstdata: sekk med ord (bag of words).

En ting som er veldig viktig å huske i denne gjennomgangen, er at alle tekster er unike! Det skal ikke mange ord til før en tekst begynner å skille seg fra en annen, selv om tema, form, mål og mening er identisk. Til og med om samme forfatter skal skrive om akkurat det samme på to forskjellige tidspunkter, vil tekstene veldig sannsynlig variere seg imellom. Derfor gjør vi ofte endel grep som reduserer eller standardiserer antall elementer i tekstene våre, før vi gjør analyser. Dette er det vi her forstår som preprosessering.

Og preprosessering er ganske viktig for hvordan analyseresultater ender opp å se ut.

5.1 Sekk med ord

Ta for eksempel spor 6 på No.4-albumet vi allerede har jobbet med – Regndans i skinnjakke. Hvis vi skal følge en vanlig antagelse i kvantitativ tekstanalyse – “sekk med ord” eller bag of words – skal vi kunne forstå innholdet i en tekst hvis vi deler opp teksten i segmenter, putter det i en pose, rister posen og tømmer det på et bord. Da vil denne sangen for eksempel se slik ut:

regndans <- readLines("./data/regndans.txt")

bow <- regndans %>%
    str_split("\\s") %>%
    unlist()

set.seed(984301)

cat(bow[sample(1:length(bow))])
## begynner kaffe i på Ta backflip Prøver rustfarva, når Gresstrå Drikke skinnjakke er I på I TV-middager av Bare Se med krystalliserer mеd hele Se Bjørkeblader hele i i hjem i smilehulla jeg livet Tusen varmluftsballonger noen dine det i [?] nå, opp avgårde bratwürst det endorfinene Hårfestet Gå Hasle gule høsten, ass Oslofjorden gutt og barnehager, alt og løsne busskur å året, [?] Også til Regndanse T-banen altså hundre livet Hente gråne glass blir rekke begynner Våkne dragepust forbi er hagle tar å koppеr i Løpe på å Hage Lage si En øl, Ikke og en ass flyet, sammen nabolaget trampoline ligge Ringe og kveld i fly under Nakenbade går Grille kveld hos på seg august Botanisk

De fleste (som ikke kan sangen fra før) vil ha vanskelig å forstå hva den egentlig handler om bare ved å se på dette. Vi kan identifisere meningsbærende ord som “Oslofjorden”, “Grille”, “trampoline”, “dragepust”, med mer. Likevel er det vanskelig å skjønne hva låtskriveren egentlig vil formidle med denne teksten. Det er dette som gjør “sekk med ord”-antagelsen veldig sterk. Språk er veldig komplekst og ordene i en tekst kan endre mening drastisk bare ved å se på en liten del av konteksten de dukker opp i. Om vi bare ser på linjen som inneholder orded “dragepust”, innser vi fort at konteksten rundt ordet gir oss et veldig tydelig bilde av hva låtskriveren mener med akkurat den linjen:

regndans[which(str_detect(regndans, "dragepust"))]
## [1] "Våkne opp mеd dragepust"

Likevel gir det oss ikke et godt bilde på hva teksten handler om i sin helhet. Det får vi bare sett ved å se på hele teksten:

## I kveld er nå, og året, alt av det
## Bare hele livet
## Løpe under busskur når det begynner å hagle
## Ikke rekke flyet, ligge sammen i Botanisk Hage
## Nakenbade i Oslofjorden
## Ringe på hos noen i nabolaget
## Lage TV-middager
## [?]
## Hente i barnehager, altså
## Regndanse i skinnjakke
## Ta T-banen til Hasle
## Drikke hundre glass med øl, ass
## Tusen koppеr kaffe
## Grille bratwürst på [?]
## Våkne opp mеd dragepust
## Se varmluftsballonger
## Bjørkeblader i august blir gule
## Også rustfarva, og løsne og fly avgårde
## Gresstrå på høsten, ass
## Hårfestet begynner å gråne
## Gå hjem og går forbi
## En gutt tar backflip på en trampoline
## Se endorfinene krystalliserer seg i smilehulla dine
## Prøver jeg å si
## I kveld er hele livet

Nå teksten gir mening! Tolkninger kan selvfølgelig variere fra individ til individ og den “riktige” tolkningen, er det bare forfatteren som vet hva er. Personlig tolker jeg denne teksten som et utløp for frustrasjon under corona-pandemien, og prospektene ved livet når samfunnet gjenåpnes, fordi jeg hørte den for første gang under nedstengningen.

Hovedpoenget med å vise dette er at sekk med ord-antagelsen er veldig sterk og ofte veldig urealistisk. Tekster (og språk generelt) er ekstremt komplekst. Det kan variere mellom geografiske områder (nasjoner, dialekter, osv), aldersgrupper, arenaer (talestol, dialog, monolog, osv), og individuell stil. Oppi alt dette skal vi prøve å finne mønster som sier noe om likhet/ulikhet mellom tekster. Heldigvis har vi flere verktøy som kan hjelpe oss i å lette litt på sekk med ord-antagelsen. Men antagelsen vil likevel alltid være der, i en eller annen form. La oss se litt på hvilke teknikker vi kan bruke for å gjøre modellering av tekst noe mer omgripelig¸ men aller først skal vi se litt på hvilke trekk som muligens ikke gir oss så mye informasjon om det vi er ute etter, eller støy, som vi ofte vil fjerne.

5.2 Fjerne trekk?

Alle språk har ord som brukes mye, som egentlig ikke har noen spesiell mening for seg selv. Ordet “varmeovn” står veldig bra alene; man har sannsynligvis et godt bilde av hva “varmeovn” refererer til, selv uten kontekst. Slike ord kalles innholdsord og skiller seg fra funksjonsord.

Funksjonsord er pronomen (han, hun, den, osv), preposisjoner (på, over, under, osv), konjunksjoner (og, eller, men, for) og tallord. Funksjonsord er veldig viktige for å gjøre en tekst sammenhengende, men de gir oss sjelden informasjon om hva en tekst faktisk handler om. Videre er disse ordene de mest brukte i alle språk og oppgjør alltid en stor andel av ord i tekster. Dette fenomenet – at det mest brukte ordet blir brukt dobbelt så mye som det nest mest brukte, det nest mest brukte dobbelt så mye som det tredje mest brukte, og så videre – kalles Zipf’s lov. Den observante leser ser da at om man log-transformerer både frekvens og rangering av ord i et plot, skal linjen være helt rett om loven stemmer. For å illustrere, trenger vi endel data. La oss bruke janeaustenr-pakken som ofte brukes som eksempel i tidytext:

library(janeaustenr)
library(dplyr)
library(tidytext)
library(ggplot2)

original_books <- austen_books() %>%
  group_by(book) %>%
  mutate(line = row_number()) %>%
  ungroup()


tidy_books <- original_books %>%
  unnest_tokens(word, text) %>% 
  count(word) %>% 
  arrange(desc(n))

tidy_books %>% head(300) %>% 
  ggplot(., aes(x = 1:300, y = n)) +
  geom_point() +
  geom_line(aes(group = 1)) +
  scale_y_continuous(trans = "log") +
  scale_x_continuous(trans = "log") +
  geom_smooth(method = "lm", se = FALSE) +
  ggrepel::geom_label_repel(aes(label = word)) +
  ggdark::dark_theme_classic() +
  labs(x = "Rangering (log)", y = "Frekvens (log)", title = "Zipf's lov illustrasjon")

For at loven skal “stemme”, må alle ordene ligge langs den gule linja. Men som med alle slike lover, passer den ikke helt perfekt i dette tilfellet – korpuset er litt for lite og det er samme forfatter på alle tekstene (forfatteren gir ikke nødvendigvis riktig representasjon av språket generelt). Den illusterer likevel poenget ganske greit. Ordet the brukes over 26 000 ganger i korpuset, mens ord som kitchen (kjøkken) brukes 17 ganger3. Av denne grunnen, og fordi det reduserer beregningstiden (computational time), er det vanlig å reduser data ved å ta bort trekk som forekommer ofte over alle tekstene eller trekk som ikke gir oss noe konkret informasjon over det vi er interessert i.

5.2.1 Stoppord

Det vi kaller stoppord er noe man ofte fjerner før vi kjører analyser. Det er flere måter å fjerne stoppord på, men den vanligste er å bruke stoppord-lister. For norsk har pakken snowball den mest brukte stoppordlista. Vi har tilgang til denne gjennom quanteda-pakken:

Klikk her for å vise norske stoppord
quanteda::stopwords("no")
##   [1] "og"        "i"         "jeg"       "det"       "at"        "en"       
##   [7] "et"        "den"       "til"       "er"        "som"       "på"       
##  [13] "de"        "med"       "han"       "av"        "ikke"      "ikkje"    
##  [19] "der"       "så"        "var"       "meg"       "seg"       "men"      
##  [25] "ett"       "har"       "om"        "vi"        "min"       "mitt"     
##  [31] "ha"        "hadde"     "hun"       "nå"        "over"      "da"       
##  [37] "ved"       "fra"       "du"        "ut"        "sin"       "dem"      
##  [43] "oss"       "opp"       "man"       "kan"       "hans"      "hvor"     
##  [49] "eller"     "hva"       "skal"      "selv"      "sjøl"      "her"      
##  [55] "alle"      "vil"       "bli"       "ble"       "blei"      "blitt"    
##  [61] "kunne"     "inn"       "når"       "være"      "kom"       "noen"     
##  [67] "noe"       "ville"     "dere"      "som"       "deres"     "kun"      
##  [73] "ja"        "etter"     "ned"       "skulle"    "denne"     "for"      
##  [79] "deg"       "si"        "sine"      "sitt"      "mot"       "å"        
##  [85] "meget"     "hvorfor"   "dette"     "disse"     "uten"      "hvordan"  
##  [91] "ingen"     "din"       "ditt"      "blir"      "samme"     "hvilken"  
##  [97] "hvilke"    "sånn"      "inni"      "mellom"    "vår"       "hver"     
## [103] "hvem"      "vors"      "hvis"      "både"      "bare"      "enn"      
## [109] "fordi"     "før"       "mange"     "også"      "slik"      "vært"     
## [115] "være"      "båe"       "begge"     "siden"     "dykk"      "dykkar"   
## [121] "dei"       "deira"     "deires"    "deim"      "di"        "då"       
## [127] "eg"        "ein"       "eit"       "eitt"      "elles"     "honom"    
## [133] "hjå"       "ho"        "hoe"       "henne"     "hennar"    "hennes"   
## [139] "hoss"      "hossen"    "ikkje"     "ingi"      "inkje"     "korleis"  
## [145] "korso"     "kva"       "kvar"      "kvarhelst" "kven"      "kvi"      
## [151] "kvifor"    "me"        "medan"     "mi"        "mine"      "mykje"    
## [157] "no"        "nokon"     "noka"      "nokor"     "noko"      "nokre"    
## [163] "si"        "sia"       "sidan"     "so"        "somt"      "somme"    
## [169] "um"        "upp"       "vere"      "vore"      "verte"     "vort"     
## [175] "varte"     "vart"

De fleste vil umiddelbart se at det er noen problemer med denne stoppordboken: den har både nynorsk- og bokmålord, den har mange ord som brukes ekstremt sjelden, og mangler noen viktige funksjonsord (som “hvilket”). Skulle vi likevel sammenligne de mest brukte ordene i No.4-tekstene, ser vi at det er mer mening i dataene når vi har fjernet

library(tidytext)

load("./data/no4.rda")

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst) %>%
  count(token)

# Med stoppord
no4_tokens %>%
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         at          5
##  2     1 Parentes         var         5
##  3     2 En av de levende jeg        32
##  4     2 En av de levende være       17
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       du         10
##  7     4 Hold deg fast    du         15
##  8     4 Hold deg fast    deg        14
##  9     5 Feil sted        du         27
## 10     5 Feil sted        er         19
## # ... with 14 more rows
# Uten stoppord
no4_tokens %>%
  filter(token %in% quanteda::stopwords("no") == FALSE) %>% 
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         fortell     2
##  2     1 Parentes         funnet      2
##  3     2 En av de levende levende    11
##  4     2 En av de levende alltid      8
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       tid         7
##  7     4 Hold deg fast    fast       13
##  8     4 Hold deg fast    hold       13
##  9     5 Feil sted        vei        10
## 10     5 Feil sted        feil        3
## # ... with 14 more rows

En alternativ måte å beregne stoppord på, er å bruke TF-IDF, eller rettere sagt IDF-delen av TF-IDF til å regne ut hvile ord som er minst unike over alle tekstene i korpuset.

idf_stop <- no4_tokens %>%
  bind_tf_idf(token, titler, n) %>% 
  ungroup() %>% 
  select(token, idf) %>% 
  unique() %>% 
  arrange(idf)

idf_stop
## # A tibble: 492 x 2
##    token    idf
##    <chr>  <dbl>
##  1 det   0     
##  2 jeg   0     
##  3 er    0.0870
##  4 ikke  0.182 
##  5 på    0.182 
##  6 å     0.182 
##  7 alt   0.182 
##  8 du    0.288 
##  9 meg   0.288 
## 10 som   0.288 
## # ... with 482 more rows

Fordelen med å gjøre det på denne måten, er at stoppordlisten tilpasser seg korpuset man jobber med. Om man, for eksempel, har hange stortingstaler, vil ord som president, representant, storting, osv være ganske meningsløse fordi de brukes så ofte, og vil ha lav IDF.

Det er likevel også noen utfordringer med denne metoden å identifisere stoppord. Det viktiste er hvor man skal sette grensen for hva som er et stoppord og ikke. Her er det ingen fasit, men krever god inspeksjon av data og litt eksperimentering. I akkurat No.4-albumet er det spesielt vanskelig å sette en grense fordi det ikke er et stort korpus; ord som åpenbart er stoppord får ikke mulighet til å bli brukt nok til å få lav IDF.

La oss likevel se på toppord etter å ha fjernet de ordene som har laver IDF enn 1.

idf_stop <- idf_stop %>% 
  filter(idf < 1)

no4_tokens %>%
  filter(token %in% idf_stop$token == FALSE) %>% 
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         fortell     2
##  2     1 Parentes         funnet      2
##  3     2 En av de levende være       17
##  4     2 En av de levende skal       13
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       hvilken     7
##  7     4 Hold deg fast    fast       13
##  8     4 Hold deg fast    hold       13
##  9     5 Feil sted        vei        10
## 10     5 Feil sted        feil        3
## # ... with 14 more rows

Resultatet blir ikke så veldig forskjellig fra å bruke stoppordlisten, som kanskje er et bra tegn.

5.2.2 Punktsetting og tall

Andre ting som er vanlige å fjerne fra et korpus før man transformerer til tall, er punktsetting og tall. Punktsetting er vanlig å fjerne, fordi det ikke gir oss noe særlig informasjon i en standard sekk med ord-modell. Likevel kan punktsetting være relevant informasjon om man vil dele opp tekster i for eksempel setninger. Det kan også være relevant å ta vare på ting som paragraftegnet (§) om man jobber med lovtekster. Tenk nøye gjennom hvilke trekk du fjerner, før du fjerner dem.

I unnest_tokens()-funksjonen fra tidytext fjernes punktsetting automatisk (men ikke alt):

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst) 

table(str_detect(no4_tokens$token, "[[:punct:]]"))
## 
## FALSE  TRUE 
##  2395     2
no4_tokens$token %>% 
  .[which(str_detect(., "[[:punct:]]"))]
## [1] "you're" "you're"

Hvis du vil ta vare på punksetting kan du spesifisere dette i unnest_tokens():

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                strip_punct = FALSE) 

table(str_detect(no4_tokens$token, "[[:punct:]]"))
## 
## FALSE  TRUE 
##  2395   250

Videre kan vi spesifisere at tall skal fjernes (default er at de ikke fjernes):

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                strip_numeric = TRUE) 

table(str_detect(no4_tokens$token, "[0-9]"))
## 
## FALSE 
##  2394

5.3 Rotform av ord

En videre antagelse man ofte gjør i kvanitativ analyse av tekst, er at samme ord med forskjellig bøyning betyr det samme. For eksempel at “hus” og “huset” egentlig er samme ord. Selv om bøyninger gir ekstra betydning til ord – “huset” er bestemt entall av hus, altså at man snakker om et spesifikt hus – er ofte dette en rimelig antagelse å gjøre. Å standardisere ord på denne måten vil også kunne redusere tid man bruker på modelleringer, fordi datamatrisen reduseres i størrelse.

Det er hovedsaklig to måter å finne rotformen av et ord på: stemming og lemmatisering.

5.3.1 Stemming

Stemming finner rotformen av et ord ved å kutte det ned til sitt minste komponent som gir mening uten at det blir et annet ord (i de fleste tilfeller).

stem1 <- tokenizers::tokenize_words("det satt to katter på et bord") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

stem2 <- tokenizers::tokenize_words("det satt en katt på et bordet") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

cbind(stem1, stem2, samme = stem1 == stem2)
##      stem1  stem2  samme  
## [1,] "det"  "det"  "TRUE" 
## [2,] "satt" "satt" "TRUE" 
## [3,] "to"   "en"   "FALSE"
## [4,] "katt" "katt" "TRUE" 
## [5,] "på"   "på"   "TRUE" 
## [6,] "et"   "et"   "TRUE" 
## [7,] "bord" "bord" "TRUE"

Som vi ser, fungerer dette ganske godt! Problemene med stemming oppstår når vi bøying av ord er uregelmessig (for eksempel svake verb):

stem1 <- tokenizers::tokenize_words("jeg har én god fot og én dårlig hånd") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

stem2 <- tokenizers::tokenize_words("jeg har to gode føtter og to dårlige hender") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

cbind(stem1, stem2, samme = stem1 == stem2)
##       stem1  stem2  samme  
##  [1,] "jeg"  "jeg"  "TRUE" 
##  [2,] "har"  "har"  "TRUE" 
##  [3,] "én"   "to"   "FALSE"
##  [4,] "god"  "god"  "TRUE" 
##  [5,] "fot"  "føtt" "FALSE"
##  [6,] "og"   "og"   "TRUE" 
##  [7,] "én"   "to"   "FALSE"
##  [8,] "dår"  "dår"  "TRUE" 
##  [9,] "hånd" "hend" "FALSE"

Her fungerer stemmingen godt på de regelmessige adjektivene (“god/gode” og “dårlig/dårlige”), mens den ikke fungerer på de uregelmessige substantivene (“fot/føtter” og “hånd/hender”). Noen vil kanksje påpeke at det “hånd/hender” og “fot/føtter” virkelig ikke er det samme, og det er en vurdering man må gjøre. Det vil uansett (nesten) alltid være tilfelle at samme tekst med og uten stemming (og lemmatisering – se under) er mer lik seg selv enn en helt annen tekst.

5.3.2 Lemmatisering

Lemmatisering skiller seg fra stemming ved at man bruker konteksten bruker en trent modell som tolker den grammatiske formen til et ord og finner rotformen til dette ordet med en ordbok. Dette gjør at man letter på problemet der ord er like, men betyr forskjellige ting i forskjellige kontekster. For eksempel vil ordet “merke” kunne bety både et fysisk merke som substantiv (arr for eksempel) og det å merke noe (“merke at noe skjer”) som verb. Lemmatisering skjer gjerne ved at man bruker en tagger som analyserer teksten man gir og spytter ut litt forskjellige egenskaper ved hvert ord i teksten.

For norsk er det litt begrensede ressurser på lett tilgjengelige lemmatiserere. Den enkleste å bruke kommer fra pakken spacyr (samme forfattere som quanteda). Her må man både ha en fungerende versjon av Python og spaCy før man installerer spacyr i R. I tillegg må man installere språkpakker for de språkene man skal bruke. For norsk, bruker vi her nb_core_news_lg.

library(spacyr)
spacy_initialize("nb_core_news_lg")

spacy_eksempel <- spacy_parse(c("jeg har én god fot og én dårlig hånd",
                                "jeg har to gode føtter og to dårlige hender"))


spacy_eksempel
##    doc_id sentence_id token_id   token  lemma   pos entity
## 1   text1           1        1     jeg    jeg  PRON       
## 2   text1           1        2     har     ha  VERB       
## 3   text1           1        3      én     én   NUM       
## 4   text1           1        4     god    god   ADJ       
## 5   text1           1        5     fot    fot  NOUN       
## 6   text1           1        6      og     og CCONJ       
## 7   text1           1        7      én     én   NUM       
## 8   text1           1        8  dårlig dårlig   ADJ       
## 9   text1           1        9    hånd   hånd  NOUN       
## 10  text2           1        1     jeg    jeg  PRON       
## 11  text2           1        2     har     ha  VERB       
## 12  text2           1        3      to     to   NUM       
## 13  text2           1        4    gode    god   ADJ       
## 14  text2           1        5  føtter    fot  NOUN       
## 15  text2           1        6      og     og CCONJ       
## 16  text2           1        7      to     to   NUM       
## 17  text2           1        8 dårlige dårlig   ADJ       
## 18  text2           1        9  hender   hånd  NOUN

Her ser vi at lemma på “hånd”/“hender” har blitt “hånd” og “fot”/“føtter” har blitt “fot”. Akkurat som vi vil. Likevel er ikke lemmatisereren til spacyr helt perfekt og man får en advarsel om dette når man kjører taggeren. Variablene vi får av taggeren er:

Variabel Beskrivelse
doc_id Id for teksten
sentence_id Indikator for setningsnummer i teksten
token_id Indeks for ord i setningen
token Den originale versjonen av ordet i teksten
lemma Lemmatisert (rotform) ord
pos Part-of-speech (taledeler)
entity Navngitt enhet (named entity) som Oslo, Solveig, Slottet, etc

Siden spaCy ikke er alltid fungerer på lemmatisering, vil vi også nevne at Universitetet i Oslo og Universitetet i Bergen har sammarbeidet om å lage en tagger, som virker veldig godt. Og vi anbefaler denne om man skal bruke tagger i en evt. masteroppgave eller lignende. Taggeren heter Oslo-Bergen-tagger (OBT). Den er ikke veldig enkel å sette opp (det enkleste er å sette det opp som via en docker container), men for å eksemplifisere hvordan den virker, har jeg kjørt stem2-teksten over gjennom taggeren og leser resultatet inn i R ved hjelp av read_obt()-funksjonen i pakken stortingscrape:

tekst2 <- stortingscrape::read_obt("./data/lemmatisering/tekst2_tag.txt")

tekst2
## # A tibble: 10 x 7
## # Groups:   sentence [1]
##    sentence index token    lwr      lemma   pos   morph                  
##       <dbl> <int> <chr>    <chr>    <chr>   <chr> <chr>                  
##  1        1     1 jeg      jeg      jeg     pron  "ent pers hum nom 1"   
##  2        1     2 har      har      ha      verb  "pres <aux1/perf_part>"
##  3        1     3 to       to       to      det   "fl kvant"             
##  4        1     4 gode     gode     god     adj   "fl pos"               
##  5        1     5 føtter  føtter  fot     subst "appell mask ub fl"    
##  6        1     6 og       og       og      konj  ""                     
##  7        1     7 to       to       to      det   "fl kvant"             
##  8        1     8 dårlige dårlige dårlig adj   "fl pos"               
##  9        1     9 hender   hender   hånd   subst "appell fem ub fl"     
## 10        1    10 .        .        $.      clb   "<<< <punkt> <<<"

Her har vi fått et datasett hvor hver rad er et ord (inkl. punktsetting) og kolonnene er forskjellige egenskaper ved dette ordet. Disse variablene viser følgende:

Variabel Beskrivelse
sentence Indikator for setningsnummer i teksten
index Indeks for ord i setningen
token Den originale versjonen av ordet i teksten
lwr Den originale versjonen av ordet i teksten med små bokstaver
lemma Lemmatisert (rotform) ord
pos Part-of-speech (taledeler)
morph Morfologi (oppbyggingen av ordet via dets minste deler)

Vi diskuterer taledeler litt mer under, og morfologi vil vi ikke bruke noe særlig tid på her, selv om det kan være veldig interessant. Det vi skal legge merke til er at kolonnen lemma viser at ordene “hender” og “føtter” har blitt bøyd riktig til “hånd” og “fot”.

5.4 Taledeler (parts of speech)

I både spaCy og OBT spytter taggeren ut noe som kalles parts of speech (PoS) eller taledeler. Dette er, kort sagt, den grammatiske formen til et ord. Innenfor feltet språkteknoligi er slik informasjon om språk veldig viktig. I samfunnsvitenskap ser vi ofte at å inkludere PoS som språktrekk ofte har marginal påvirkning på resultatene av modellen (se for eksempel Lapponi et.al (2019).

Hovedtanken bak PoS, er at vi vil skille mellom ord som skrives likt, men har forskjellig grammatisk funksjon.

grei1 <- "den snegler seg fremover"
grei2 <- "det er mange snegler her"

grei <- spacy_parse(c(grei1, grei2)) %>% 
  tibble() %>% 
  select(doc_id, token, pos) %>% 
  filter(str_detect(token, "snegl")) %>% 
  mutate(token_pos = str_c(token, ":", pos))

grei
## # A tibble: 2 x 4
##   doc_id token   pos   token_pos   
##   <chr>  <chr>   <chr> <chr>       
## 1 text1  snegler VERB  snegler:VERB
## 2 text2  snegler NOUN  snegler:NOUN

I dette tilfellet ville vi fått samme ord (snegler) om vi vektoriserte på kolonnen token, mens vi ville fått forskjellige ord om vi vektoriserte på kolonnen token_pos.

5.5 ngrams

Når vi lager en “sekk med ord”, splitter vi ofte teksten inn i ett og ett ord. Ordene kaller vi gjerne tokens (derav funksjonen unnest_tokens()). Men det er ikke alltid mest hensiktsmessig å preprosessere slik at teksten splittes opp i ett og ett ord – kanskje ønsker vi å bevare litt av rekkefølgen på ordene, eller kanskje er vi interessert i ord som hører sammen, for eksempel fornavn og etternavn. Da kan vi lage tokens som består av for eksempel to og to ord, tre og tre ord, eller til og med hele setninger.

Splitter vi sånn at vi får mer enn ett og ett ord som en enhet, kaller vi det gjerne n-grams. Ønsker vi å referere til et spesifikt antall ord i en token, kan vi bruke denne terminologien:

  • Ett og ett ord: Unigram
  • To og to ord: Bigrams
  • Tre og tre ord: Trigrams

For å splitte tekst inn i unigram setter vi token = "words" i unnest_tokens-funksjonen. Dette er også default for funksjonen, så dersom vi ikke spesifiserer noen ting, så er det unigrams vi får.

no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                token = "words")
## # A tibble: 2,397 x 3
## # Groups:   spor, titler [12]
##     spor titler   token     
##    <int> <chr>    <chr>     
##  1     1 Parentes forstyrrer
##  2     1 Parentes jeg       
##  3     1 Parentes eller     
##  4     1 Parentes har       
##  5     1 Parentes du        
##  6     1 Parentes tid       
##  7     1 Parentes til       
##  8     1 Parentes å         
##  9     1 Parentes høre      
## 10     1 Parentes på        
## # ... with 2,387 more rows

For å hente ut bigrams, sett token = "ngrams" og n = 2. Kan du tenke deg hva vi ville fått dersom vi hadde satt n = 3?

no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                token = "ngrams",
                n = 2) 
## # A tibble: 2,385 x 3
## # Groups:   spor, titler [12]
##     spor titler   token         
##    <int> <chr>    <chr>         
##  1     1 Parentes forstyrrer jeg
##  2     1 Parentes jeg eller     
##  3     1 Parentes eller har     
##  4     1 Parentes har du        
##  5     1 Parentes du tid        
##  6     1 Parentes tid til       
##  7     1 Parentes til å         
##  8     1 Parentes å høre        
##  9     1 Parentes høre på       
## 10     1 Parentes på meg        
## # ... with 2,375 more rows

5.6 Word embeddings

Når vi skal jobbe med tekst, må vi finne en måte å gjøre om teksten til tall. Datamaskinen jobber best med tall. Prosessen med å gjøre om ord til tall kalles “vektorisering”. Det finnes flere måter å vektorisere på, deriblant:

  • Sekk av ord (bag of words): Gir oss frekvensen av ord per dokument.
  • TF-IDF: Gir oss frekvens av ord per dokument, vektet etter hvor hyppig ordet forekommer i dokumentmassen.
  • Word embeddings: Gir oss en vektor i et lav-dimensjonalt rom for hvert ord.

Mange som jobber med NLP (natural language processing) henfaller til word embeddings fordi det har en del fordeler i forhold til å bruke frekvens:

  • Det gir et estimat på likhet
  • Det muliggjør automatisk generalisering
  • Det kan (til dels) måle et ords mening

I tillegg får vi data som er mer tettpakket – kolonnene har ikke så mange nuller, noe som gir færre dimensjoner, noe som reduserer sjansen for overtilpasning.

Det finnes flere pakker for word embeddings i R, for eksempel word2vec, GloVe og fastText.

Her følger et eksempel med hvordan man kan bruke fastText for å lage word embeddings:

Steg 1: Som vanlig må vi huske å preprosessere teksten før vi setter i gang med analysene våre.

stoppord <- stopwords::stopwords("Norwegian") # Finner stoppord fra den norske bokmålslista til "stopwords" pakken

stoppord_boundary <- str_c("\\b", stoppord, "\\b", # Lager en vektor med "word boundary" for å ta ut ord fra en streng
                           collapse = "|") # Setter | mellom hver ord for å skille dem fra hverandre med "eller"-operator

no4_prepped <- no4 %>%
  mutate(tekst = str_to_lower(tekst), # Setter all tekst til liten bokstav
         tekst = str_replace_all(tekst, "[0-9]+", ""), # Fjerner tall fra teksten
         tekst = str_squish(tekst), # Fjerner whitespace
         tekst = str_replace_all(tekst, "\\b\\w{1,1}\\b", ""), # Fjerner enkeltbokstaver
         tekst = str_replace_all(tekst, stoppord_boundary, ""), # Fjerner stoppord
         tekst = str_replace_all(tekst, "[:punct:]", "")) # Fjerner all punktsetting

Steg 2: Fasttext er en algoritme utviklet av Facebook. De har laget den slik at den skal fungere for alle utviklere der ute, enten de jobber i terminalen, i Python, i Java, i R, eller i noe annet. Derfor krever de en input som er litt utenom det vanlige – et vanlig tekstdokument, altså en .txt fil. Dette kan vi lage i R med koden under.

no4_tekster <- tempfile() # Oppretter en midlertidig fil på PCen
writeLines(text = no4_prepped %>% pull(tekst), con = no4_tekster) # I denne filen skriver vi inn teksten fra datasettet. 

Steg 3: Nå kan vi kjøre modellen for å lage word embeddings. Noen av valgene vi må ta er:

  • Hvor stort skal kontekstvinduet være? Altså hvor mange ord foran og bak hovedordet skal algoritmen bruke for å forstå konteksten.
  • Hvor mange dimensjoner skal det være? Her får vi automatisk 100 dimensjoner. For å endre dette måtte vi kjørt modellen via terminalen.
  • Hvilken modell skal vi bruke? Fasttext tilbyr både cbow og skipgram.
library(fastTextR)

ft_cbow <- ft_train(no4_tekster, 
                    type = "cbow", # Velger cbow modell
                    control = ft_control(window_size = 5L)) # Setter kontekstvinduet til 5

ft_skipgram <- ft_train(no4_tekster, 
                        type = "skipgram", # Velger skipgram modell
                        control = ft_control(window_size = 5L))

Vi kan finne ord-vektorene med ft_word_vectors. Legg merke til at de går til 100. Vi har altså 100 dimensjoner. Hadde vi brukt “sekk av ord”, hadde vi hatt like mange dimensjoner som vi har ord, altså nesten 1000. Vi har, med andre ord, redusert antall dimensjoner ganske kraftig.

ft_word_vectors(ft_cbow, c("fordi", "himmel"))
##                [,1]          [,2]         [,3]          [,4]          [,5]
## fordi  0.0004229803 -3.126769e-05 0.0001531525 -0.0000911542  0.0005640839
## himmel 0.0003400762 -2.810812e-04 0.0003625524 -0.0001698947 -0.0002005034
##                [,6]          [,7]          [,8]          [,9]        [,10]
## fordi  0.0001611611  9.468633e-06 -0.0005698582  2.882037e-04 1.239537e-04
## himmel 0.0002343899 -4.229283e-04 -0.0002758013 -2.557085e-05 5.703386e-05
##                [,11]         [,12]        [,13]         [,14]         [,15]
## fordi  -4.968944e-05 -0.0005556891 2.990265e-04 -4.937951e-04  0.0003009799
## himmel  2.508874e-04 -0.0001195825 7.999406e-07 -9.304351e-05 -0.0004802363
##               [,16]         [,17]         [,18]        [,19]         [,20]
## fordi  0.0004996158 -0.0006972131  0.0003386495 2.059648e-04  0.0001499537
## himmel 0.0003660462  0.0002463926 -0.0001130784 1.981639e-05 -0.0005010382
##                [,21]         [,22]         [,23]        [,24]         [,25]
## fordi  -0.0006710046 -0.0002009657 -0.0005896706 -0.000539655 -6.203828e-04
## himmel  0.0002415862  0.0003886326  0.0002367772 -0.000117599  2.979174e-05
##               [,26]         [,27]         [,28]        [,29]         [,30]
## fordi  0.0003445543  1.054367e-04 -7.411114e-05 0.0006279270 -0.0005203970
## himmel 0.0003994071 -1.302021e-05  4.160340e-05 0.0005499916 -0.0002487123
##                [,31]         [,32]         [,33]         [,34]         [,35]
## fordi  -1.410886e-04  0.0002264874  0.0006179944 -0.0002261649 -2.146827e-04
## himmel -7.161538e-05 -0.0002718160 -0.0003046337  0.0005525587 -8.663347e-05
##               [,36]         [,37]         [,38]         [,39]        [,40]
## fordi  7.682834e-05  0.0006319320  0.0005043782 -0.0004292535 0.0007084003
## himmel 4.861114e-04 -0.0005046887 -0.0001433692  0.0004297458 0.0003608722
##                [,41]        [,42]        [,43]        [,44]         [,45]
## fordi  -0.0007128998 0.0001443866 0.0004862696 1.229172e-04 -0.0001680819
## himmel  0.0003313405 0.0005343112 0.0002021801 9.993793e-05 -0.0001391745
##                [,46]         [,47]         [,48]        [,49]         [,50]
## fordi   4.094304e-04  0.0003071116 -0.0004071383 0.0006898485  0.0005943870
## himmel -6.519686e-05 -0.0002976863 -0.0001010489 0.0001225848 -0.0002687823
##                [,51]         [,52]         [,53]         [,54]         [,55]
## fordi   0.0000633754 -0.0002592132 -3.742145e-04 -0.0003261300 -0.0005033756
## himmel -0.0004098963 -0.0001246714 -2.255533e-05 -0.0002819263 -0.0004302305
##                [,56]         [,57]         [,58]         [,59]         [,60]
## fordi   0.0005689726 -0.0007093063 -0.0006017384 -0.0004089687  5.104363e-05
## himmel -0.0001421283  0.0005541204  0.0005207795  0.0003844925 -3.888329e-04
##                [,61]         [,62]         [,63]         [,64]        [,65]
## fordi   0.0006562477  0.0004448018  5.268937e-05  0.0002097173 0.0006626237
## himmel -0.0002631767 -0.0003501290 -5.216807e-04 -0.0001643755 0.0001456836
##                [,66]         [,67]         [,68]         [,69]         [,70]
## fordi   0.0004130471  0.0005810333  0.0001703538 -0.0004132454 -0.0005853543
## himmel -0.0002122321 -0.0002969235 -0.0004152645  0.0005410713 -0.0004272975
##               [,71]        [,72]         [,73]         [,74]         [,75]
## fordi  0.0004889697 2.109571e-04 -0.0001353624  0.0001184932 -0.0003778868
## himmel 0.0002760292 6.502134e-05 -0.0001330448 -0.0004659508 -0.0002136038
##                [,76]        [,77]         [,78]        [,79]         [,80]
## fordi  -0.0001506362 0.0006121492 -0.0006414849 0.0002733775 -0.0004687168
## himmel -0.0003862265 0.0001411103  0.0001751131 0.0003818365  0.0000309874
##                [,81]         [,82]        [,83]         [,84]         [,85]
## fordi  -0.0002362550 -0.0004116326 2.613955e-05 -6.308833e-04 -0.0006163829
## himmel -0.0001708977  0.0005427501 1.457292e-04  8.805923e-05 -0.0003328912
##               [,86]        [,87]         [,88]         [,89]        [,90]
## fordi  6.234720e-04 0.0003603076 -0.0002887875 -0.0004656472 6.827115e-05
## himmel 2.321036e-05 0.0003714993  0.0002933164  0.0004211639 1.341513e-04
##               [,91]        [,92]         [,93]        [,94]         [,95]
## fordi  3.228633e-05 0.0004867699  0.0002592817 0.0002948412 -0.0005433591
## himmel 3.277051e-04 0.0003368929 -0.0002967137 0.0003651592  0.0003919543
##                [,96]         [,97]         [,98]         [,99]       [,100]
## fordi  -0.0001487845 -0.0002300390 -0.0005808019 -0.0002429863 0.0003132335
## himmel  0.0005056034  0.0005507146  0.0002853644  0.0001191367 0.0002650023

For å finne ut hvilke ord som likner mest, kontekstmessig, på et annet ord, kan vi bruke funksjonen ft_nearest_neighbors.

ft_nearest_neighbors(ft_cbow, "himmel", k = 5L)
##     alltid   egentlig       fast        alt        ser 
## 0.20526281 0.16303207 0.12392932 0.09339610 0.08974258

Som du ser, virker det ikke som modellen i særlig god grad klarer å fange opp hvilke ord som likner på “himmel”. Ved mindre vi har ekstremt store mengder med data å trene våre word embeddings på, er det ofte best å bruke ferdigtrent data. Du kan finne facebook sine ferdigtrente word embeddings i diverse språk her: https://fasttext.cc/docs/en/crawl-vectors.html

6 Veildedet læring

7 Ikke-veiledet læring

8 Ordbøker

9 Tekststatistikk

9.1 Likhet

9.2 Avstand

9.3 Lesbarhet

9.4 Uttrykk

10 Sentiment

10.1 NorSentLex

Det har lenge vært ganske lite ressurser for sentimentanalyse på norsk. Barnes et al. (2019) har ganske nylig satt sammen en stor ordbok med positive og negative ord i for både fullform og lemmatisert form med PoS-tags4. Disse ordbøkene bygger på en en oversatt og manuelt korrigert engelsk korpus av kundetilbakemeldinger (Hu and Liu 2004) og er pakket i både rå .txt-filer og .json-filer. Heldigvis har en tulling også konvertert dette til en pakke i R: NorSentLex (for øyblikket ikke på CRAN). For å laste inn/ned ordbøkene, kan du enten installere R-pakken med devtools::install_github("martigso/NorSentLex") eller bruke det du lærte i skrape-delen av denne notatboken på de originale filene. La oss illustrer med R-pakken:

# devtools::install_github("martigso/NorSentLex")

# library(NorSentLex)
# Ordbøker i fullform
names(nor_fullform_sent)
## [1] "negative" "positive"
# Ordbøker for lemma med PoS-tags
names(nor_lemma_sent)
## [1] "lemma_adj_negative"  "lemma_adj_positive"  "lemma_noun_negative"
## [4] "lemma_noun_positive" "lemma_padj_negative" "lemma_padj_positive"
## [7] "lemma_verb_negative" "lemma_verb_positive"

Hvis vi vil se på, for eksempel, noen positive ord i fullform, kan vi gå inn i listen nor_fullform_sent og listeelementet som heter $positive:

nor_fullform_sent$positive %>% head()
## [1] "absolutt"    "absolutta"   "absolutte"   "absoluttene" "absolutter" 
## [6] "absoluttet"
nor_fullform_sent$positive %>% tail()
## [1] "ønsket"        "ønskete"       "ønskt"         "ønskte"       
## [5] "øyeblikkelig"  "øyeblikkelige"
nor_fullform_sent$positive %>% sample(., 6)
## [1] "lett"       "kjæresten"  "sympatisør" "underbart"  "tilrå"     
## [6] "dufte"

Det er ikke nødvendigvis alt som gir mening som positive og negative ord, med mindre man har i bakhodet at dette er basert på kundeanmeldelser. Så vær varsom!

Om vi videre vil bruke den lemmatiserte ordboken, kan vi også trekke dette ut enkelt fra de forskjellige elementene i nor_lemma_sent. Si at vi skal bruke bare positive substantiv:

nor_lemma_sent$lemma_noun_positive %>% sample(., 6)
## [1] "skarpsinn"    "engel"        "jubilant"     "fortjeneste"  "enighet"     
## [6] "forsiktighet"

Nå når vi vet hvordan vi finner ordboken, gjenstår å lære hvordan vi bruker den. La oss bruke fullformord fra No.4-albumet data-mappen (no4.rda) som eksempel. Først splitter vi opp teksten i ord (tokens):

library(tidytext)

load("./data/no4.rda")

no4 <- no4 %>% 
  group_by(titler) %>% 
  unnest_tokens(ord, tekst)

Så kryss-refererer vi hvert ord med de positive og negative fullformordene i ordboken:

no4$pos_sent <- ifelse(no4$ord %in% nor_fullform_sent$positive, 1, 0)
no4$neg_sent <- ifelse(no4$ord %in% nor_fullform_sent$negative, 1, 0)

table(no4$pos_sent, 
      no4$neg_sent, 
      dnn = c("positiv", "negativ"))
##        negativ
## positiv    0    1
##       0 2062  117
##       1  217    1

Som vi ser, er det faktisk noen flere negative ord enn positive i albument. Men overvekten av ord er nøytrale (0 på begge). Vi kan også summere opp sentiment over sangene, og se om det er noe forskjell i sentiment mellom dem:

no4_sent <- no4 %>% 
  group_by(titler) %>% 
  summarize(pos_sent = mean(pos_sent),
            neg_sent = mean(neg_sent)) %>% 
  mutate(sent = pos_sent - neg_sent)

no4_sent
## # A tibble: 12 x 4
##    titler                                     pos_sent neg_sent     sent
##    <chr>                                         <dbl>    <dbl>    <dbl>
##  1 Alt vi ikke er                               0.100   0.0502   0.0502 
##  2 Du trenger ikke å bli stor                   0.0537  0.0604  -0.00671
##  3 En av de levende                             0.0819  0.0395   0.0424 
##  4 Feil sted                                    0.0374  0.0561  -0.0187 
##  5 Hele livet (Ft. Fredrik Høyer)               0.0421  0.0383   0.00383
##  6 Hjemme hos meg                               0.0853  0.0155   0.0698 
##  7 Hold deg fast                                0.147   0.0333   0.113  
##  8 Hvilket vi                                   0.0337  0.0506  -0.0169 
##  9 Parentes                                     0.0563  0.0423   0.0141 
## 10 Regndanse i skinnjakke (Ft. Fredrik Høyer)   0.0254  0.00847  0.0169 
## 11 Så lenge vi finnes                           0.266   0.131    0.135  
## 12 Våre beste år                                0.115   0.0513   0.0641

Ikke alverden forskjell, men noen sanger er med positive enn negative og motsatt. La oss visualisere:

no4_sent %>% 
  mutate(neg_sent = neg_sent * -1) %>% 
  ggplot(., aes(x = str_c(sprintf("%02d", 1:12),
                          ". ",
                          str_sub(titler, 1, 7),
                          "[...]"))) +
  geom_point(aes(y = neg_sent, color = "Negativ")) +
  geom_point(aes(y = pos_sent, color = "Positiv")) +
  geom_point(aes(y = sent, color = "Snitt")) +
  geom_linerange(aes(ymin = neg_sent, ymax = pos_sent), color = "gray40") +
  scale_color_manual(values = c("red", "cyan", "gray70")) +
  labs(x = NULL, y = "Sentiment", color = NULL) +
  ggdark::dark_theme_minimal() +
  theme(axis.text.x = element_text(angle = 90, vjust = .25, hjust = 0))

11 Temamodellering

12 Latente posisjoner

13 Noen tanker om videre læring

14 Oppsummering

Referanser

Barnes, Jeremy, Samia Touileb, Lilja Øvrelid, and Erik Velldal. 2019. “Lexicon Information in Neural Sentiment Analysis: A Multi-Task Learning Approach.” In Proceedings of the 22nd Nordic Conference on Computational Linguistics, Turku, Finland: Linköping University Electronic Press, 175–86. https://aclanthology.org/W19-6119.
Benoit, Kenneth, and Akitaka Matsuo. 2020. Spacyr: Wrapper to the ’spaCy’ ’NLP’ Library. https://CRAN.R-project.org/package=spacyr.
Blei, David M. 2012. Probabilistic Topic Models.” Communications of the ACM 55(4): 77–84.
Cooksey, Brian. 2014. “An Introduction to APIs.” Zapier, Inc. https://cdn.zapier.com/storage/learn_ebooks/e06a35cfcf092ec6dd22670383d9fd12.pdf.
D’Orazio, Vito, Steven T. Landis, Glenn Palmer, and Philip Schrodt. 2014. Separating the Wheat from the Chaff: Applications of Automated Document Classification Using Support Vector Machines.” Political Analysis 22(2): 224–42.
Denny, Matthew J., and Arthur Spirling. 2018. Text Preprocessing For Unsupervised Learning: Why It Matters, When It Misleads, And What To Do About It.” Political Analysis 26(2): 168–89. https://doi.org/10.1017/pan.2017.44.
Feldman, Ronen, and James Sanger. 2006a. Categorization.” In The Text Mining Handbook: Advanced Approaches in Analyzing Unstructured Data, Cambridge University Press, 64–81.
———. 2006b. Clustering.” In The Text Mining Handbook: Advanced Approaches in Analyzing Unstructured Data, Cambridge University Press, 82–93.
Finseraas, Henning, Bjørn Høyland, and Martin G. Søyland. 2021. “Climate Politics in Hard Times: How Local Economic Shocks Influence MPs Attention to Climate Change.” European Journal of Political Research 60(3): 738–47. https://ejpr.onlinelibrary.wiley.com/doi/abs/10.1111/1475-6765.12415.
Grimmer, Justin, Margaret E. Roberts, and Brandon M. Stewart. 2022. Text as Data: A New Framework for Machine Learning and the Social Sciences. Princeton University Press.
Høyland, Bjørn, and Martin Søyland. 2019. Electoral Reform and Parliamentary Debates.” Legislative Studies Quarterly 44(4): 593–615.
Hu, Minqing, and Bing Liu. 2004. “Mining and Summarizing Customer Reviews.” In Proceedings of the Tenth ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, KDD ’04, New York, NY, USA: Association for Computing Machinery, 168–77. https://doi.org/10.1145/1014052.1014073.
Jørgensen, Fredrik et al. 2019. “NorNE: Annotating Named Entities for Norwegian.” https://arxiv.org/abs/1911.12146.
Lauderdale, Benjamin E., and Alexander Herzog. 2016. Measuring Political Positions from Legislative Speech.” Political Analysis 24(3): 374–94.
Laver, Michael, Kenneth Benoit, and John Garry. 2003. Extracting Policy Positions from Political Texts Using Words as Data.” American Political Science Review 97(02): 311–31.
Liu, Bing. 2015. “Introduction.” In Sentiment Analysis: Mining Opinions, Sentiments, and Emotions, Cambridge University Press, 1–15. https://www.cambridge.org/core/books/sentiment-analysis/3F0F24BE12E66764ACE8F179BCDA42E9.
Lowe, Will. 2017. Understanding Wordscores.” Political Analysis 16(4): 356–71.
Lucas, Christopher et al. 2015. Computer-Assisted Text Analysis for Comparative Politics.” Political Analysis 23(2): 254–77.
Muchlinski, David, David Siroky, Jingrui He, and Matthew Kocher. 2016. Comparing Random Forest with Logistic Regression for Predicting Class-Imbalanced Civil War Onset Data.” Political Analysis 24(1): 87–103.
Pang, Bo, Lillian Lee, et al. 2008. “Opinion Mining and Sentiment Analysis.” Foundations and Trends in information retrieval 2(1–2): 1–135. https://www.cs.cornell.edu/home/llee/omsa/omsa.pdf.
Peterson, Andrew, and Arthur Spirling. 2018. Classification Accuracy as a Substantive Quantity of Interest: Measuring Polarization in Westminster Systems.” Political Analysis 26(1).
Rinker, Tyler W. 2021. textreadr: Read Text Documents into r. Buffalo, New York. https://github.com/trinker/textreadr.
Roberts, Margaret E. et al. 2014. Structural Topic Models for Open-Ended Survey Responses.” American Journal of Political Science 58(4): 1064–82.
Silge, Julia, and David Robinson. 2017. Text Mining with R: A Tidy Approach. O’Reilly Media, Inc. https://www.tidytextmining.com/.
Slapin, Jonathan B., and Sven-Oliver Proksch. 2008. A Scaling Model for Estimating Time-Series Party Positions from Texts.” American Journal of Political Science 52(3): 705–22.
Søyland, Martin. 2022. Stortingscrape: Scrape and Structure Raw Data from the Norwegian Parliament’s API. https://github.com/martigso/stortingscrape.
Stortinget. 2022. Stortingets Datatjeneste. https://data.stortinget.no.
Wickham, Hadley. 2020. Httr: Tools for Working with URLs and HTTP. https://cran.r-project.org/web/packages/httr/vignettes/quickstart.html.
Wilkerson, John, and Andreu Casas. 2017. “Large-Scale Computerized Text Analysis in Political Science: Opportunities and Challenges.” Annual Review of Political Science 20(1): 529–44. https://doi.org/10.1146/annurev-polisci-052615-025542.

  1. Vi bruker readr fordi den virker godt sammen med tidyverse og er noe raskere enn base-funksjonen read.csv()↩︎

  2. x <- stopp[[1]]↩︎

  3. tidy_books %>% filter(str_detect(word, "kitchen"))↩︎

  4. se: https://github.com/ltgoslo/norsentlex↩︎